package ca.scotthyndman.web.swf;

import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

/**
 * <p>
 * The SWF compiler bridge class allows the compilation of Flash SWF files from
 * Java using the MTASC compiler (and SWFMill, if specified). The bridge works
 * as follows:
 * </p>
 * <p>
 * If a swfmill library XML file is defined in the {@link BuildInfo} instance
 * passed to one of the compile methods, first swfmill is used to generate the
 * assets swf. Then MTASC uses this file as input, and the process is as
 * described below.
 * </p>
 * <p>
 * This class works by calling the MTASC compiler, saving the resulting SWF to a
 * temporary location. These temporary files are recorded by the bridge and can
 * be removed upon request by calling <code>{@link #cleanup()}</code>. The
 * clean up method can also be called automatically when the bridge is
 * finalized, either by constructing the bridge using
 * <code>{@link #SWFCompilerBridge(String, boolean)}</code> constructor, or
 * setting the flag explicitly with
 * <code>{@link #setCleanOnFinalize(boolean)}</code>.
 * </p>
 * <p>
 * Calls to the bridge's compile method are thread safe, but they should never
 * be called when there is any risk that a clean up operation is underway.
 * </p>
 * <p>
 * Usage example (can be used in conjunction with the first example at
 * http://www.mtasc.org ):
 * 
 * <pre class="example" >
 * // Specify build information, like class paths and options
 * {@link BuildInfo} info = {@link BuildInfo#BuildInfo(String, int, int, int) new BuildInfo(&quot;Tuto.as&quot;, 800, 600, 31)};
 * 
 * // Build a new compiler bridge using the current directory as the temp directory
 * SWFCompilerBridge bridge = {@link SWFCompilerBridge#SWFCompilerBridge(String, boolean) new SWFCompilerBridge(&quot;.&quot;, true)};
 * 
 * // Attempt to compile the class
 * File compiledClass = null;
 * try {
 * 	compiledClass = {@link #compileSWF(BuildInfo) bridge.compileSWF(info)};
 * } catch ({@link CompileException} e) { // occurs when the build fails
 * 	...
 * }
 * </pre>
 * 
 * And that's it. Now you can do whatever you want with the
 * <code>compiledClass</code> object.
 * </p>
 * <p>
 * Please note that in the case where a build is using swfmill, all import paths
 * must be relative to Java's working directory, not the XML file.
 * </p>
 * 
 * @see #compileSWF(BuildInfo)
 * @see #compileSWFToBytes(BuildInfo)
 * @see #setCleanOnFinalize(boolean)
 * @see #cleanup()
 * @author Scott Hyndman
 */
public class SWFCompilerBridge {

	//
	// ============= MEMBERS
	//

	/** The length of random filenames assigned to the temporary swf */
	private static final int TMP_FILENAME_LENGTH = 20;

	/** The temporary directory where compiled swfs are stored */
	protected File tmpDir;

	/** A set of swf files created by MTASC. */
	protected Set<File> temporaryFiles = new HashSet<File>();

	/**
	 * <code>true</code> if temp files should be cleand up when this instance
	 * is finalized
	 */
	protected boolean cleanOnFinalize;

	/** The path to MTASC. */
	protected String compilerPath = "mtasc";

	/** The path to swfmill. */
	protected String swfmillPath = "swfmill";

	//
	// ============= CONSTRUCTION
	//

	/**
	 * Creates a new SWF compiler bridge. An instance created this constructor
	 * will not automatically remove all temporary files when it is finalized.
	 * 
	 * @param tmpDir
	 *            The directory in which compiled swfs are stored temporarily.
	 *            This must be a path relative to the Java working directory, or
	 *            an absolute path.
	 * @throws FileNotFoundException
	 *             If the <code>tmpDir</code> directory does not exist.
	 * @see #SWFCompilerBridge(String, boolean)
	 */
	public SWFCompilerBridge(String tmpDir) throws FileNotFoundException {
		this(tmpDir, false);
	}

	/**
	 * Creates a new SWF compiler bridge.
	 * 
	 * @param tmpDir
	 *            The directory in which compiled swfs are stored temporarily.
	 *            This must be a path relative to the Java working directory, or
	 *            an absolute path.
	 * @param cleanOnFinalize
	 *            <code>true</code> if the compiler bridge should remove all
	 *            temporary swf files when it is finalized. If
	 *            <code>false</code>, these files can be removed by manually
	 *            calling the <code>cleanup()</code> method.
	 * @throws FileNotFoundException
	 *             If the <code>tmpDir</code> directory does not exist.
	 * @see #SWFCompilerBridge(String)
	 */
	public SWFCompilerBridge(String tmpDir, boolean cleanOnFinalize)
			throws FileNotFoundException {
		this.tmpDir = new File(tmpDir);
		this.setCleanOnFinalize(cleanOnFinalize);

		if (!this.tmpDir.exists()) {
			throw new FileNotFoundException("The " + tmpDir
					+ " directory does not exist");
		}
	}

	//
	// ============= DESTRUCTION
	//

	/**
	 * Removes all temporary files created during compilation.
	 * 
	 * @see java.lang.Object#finalize()
	 */
	@Override
	protected void finalize() throws Throwable {
		super.finalize();

		if (shouldCleanOnFinalize()) {
			cleanup();
		}
	}

	//
	// ============= COMPILING
	//

	/**
	 * Sets the path to the MTASC compiler. By default, it is assumed that
	 * mtasc's location is on the environment path, so <code>"mtasc"</code>
	 * will be called without a path.
	 * 
	 * @param compilerPath
	 *            The path to the MTASC compiler.
	 */
	public void setCompilerPath(String compilerPath) {
		this.compilerPath = compilerPath;
	}

	/**
	 * @return The path to the MTASC compiler. The default is
	 *         <code>"mtasc"</code>, which assumes MTASC is on the
	 *         environment path.
	 */
	public String getCompilerPath() {
		return compilerPath;
	}

	/**
	 * Sets the path to the swfmill compiler. By default, it is assumed that
	 * swfmill location is on the environment path, so <code>"swfmill"</code>
	 * will be called without a path.
	 * 
	 * @param swfmillPath
	 *            The path to the swfmill compiler.
	 */
	public void setSwfmillPath(String swfmillPath) {
		this.swfmillPath = swfmillPath;
	}

	/**
	 * @return The path to swfmill. The default is <code>"swfmill"</code>,
	 *         which assumes swfmill is on the environment path.
	 */
	public String getSwfmillPath() {
		return swfmillPath;
	}

	/**
	 * <p>
	 * Compiles a SWF and returns a file object pointing to the resulting file.
	 * </p>
	 * <p>
	 * See the <code>{@link #compileSWFToBytes(BuildInfo)}</code> method if
	 * you would like the SWF returned as a byte array.
	 * </p>
	 * <p>
	 * Please note that <code>{@link #cleanup()}</code> should never be called
	 * on this bridge instance if there is a chance that this method is running.
	 * </p>
	 * <p>
	 * This method is thread safe.
	 * </p>
	 * 
	 * @param info
	 *            An object describing instructions and options for the build
	 * @throws CompileException
	 *             If an error is encountered during compilation
	 * @return The resulting swf file
	 * @see #compileSWFToBytes(BuildInfo)
	 */
	public File compileSWF(BuildInfo info) throws CompileException {
		//
		// Create the output file
		//
		File outputFile;
		try {
			outputFile = createTempFile();
		} catch (IOException e) {
			throw new CompileException(
					"An error was encountered when trying to"
							+ " to create the temporary output file. Please see the"
							+ " inner exception for details", e);
		}

		//
		// Build the command list
		//
		List<String> commands = buildCommandList(info, outputFile);

		//
		// If we're using swfmill, first build the assets SWF
		//
		File assetsFile = null;
		if (info.buildUsesSwfmill()) {
			try {
				assetsFile = compileAssetsSWF(info);
			} catch (CompileException e) {
				outputFile.delete();
				throw e;
			}
			commands.add("-swf");
			commands.add(assetsFile.getAbsolutePath());
		}

		//
		// Create the process
		//
		Process process;
		BufferedInputStream errorStream;
		int returnVal;

		//
		// Run the process
		//
		try {
			process = new ProcessBuilder(commands).start();
			errorStream = new BufferedInputStream(process.getErrorStream());
			returnVal = process.waitFor();
		} catch (Exception e) {
			outputFile.delete(); // cleanup
			if (assetsFile != null) {
				assetsFile.delete();
			}
			throw new CompileException("An error occurred during compilation. "
					+ "See inner exception for details.", e);
		}

		//
		// Throw an exception if mtasc has issues.
		//
		if (returnVal != 0) {
			outputFile.delete(); // cleanup
			if (assetsFile != null) {
				assetsFile.delete();
			}
			try {
				throw new CompileException("MTASC encountered an error while "
						+ "compiling. The error was: "
						+ System.getProperty("line.separator")
						+ CompilerUtils.readStringFromStream(errorStream));
			} catch (IOException e) {
				throw new CompileException("MTASC encountered an error while "
						+ "compiling.");
			}
		}

		//
		// Add the file to the temporary files set
		//
		synchronized (temporaryFiles) {
			temporaryFiles.add(outputFile);
		}

		//
		// Remove the assets file if there is one
		//
		if (assetsFile != null) {
			assetsFile.delete();
		}

		return outputFile;
	}

	/**
	 * <p>
	 * Compiles a SWF and returns a byte array of the file contents.
	 * </p>
	 * <p>
	 * Please note that <code>{@link #cleanup()}</code> should never be called
	 * on this bridge instance if there is a chance that this method is running.
	 * </p>
	 * <p>
	 * This method is thread safe.
	 * </p>
	 * 
	 * @param info
	 *            An object describing instructions and options for the build
	 * @throws CompileException
	 *             If an error is encountered during compilation
	 * @return The resulting swf file
	 * @see #compileSWF(BuildInfo)
	 */
	public byte[] compileSWFToBytes(BuildInfo info) throws CompileException {
		byte[] bytes;

		//
		// Compile the swf
		//
		File swf = compileSWF(info);

		try {
			//
			// Get the input stream
			//
			InputStream input = new FileInputStream(swf);
			long length = swf.length();

			bytes = CompilerUtils.readBytesFromStream(input, length);

		} catch (IOException e) {
			throw new CompileException(
					"An error was encountered while reading the compiled swf "
							+ "file. See inner exception for details.", e);
		}

		swf.delete();

		//
		// Remove the file from the temporary files set
		//
		synchronized (temporaryFiles) {
			temporaryFiles.remove(swf);
		}

		return bytes;
	}

	/**
	 * Compiles a swfmill assets swf, and returns the result.
	 * 
	 * @param info
	 *            The object describing the build
	 * @return The temporary swfmill assets SWF
	 */
	protected File compileAssetsSWF(BuildInfo info) throws CompileException {
		//
		// Create the output file
		//
		File outputFile;
		try {
			outputFile = createTempFile();
		} catch (IOException e) {
			throw new CompileException(
					"An error was encountered when trying to"
							+ " to create the temporary assets file. Please see the"
							+ " inner exception for details", e);
		}

		//
		// Build the command list
		//
		List<String> commands = new ArrayList<String>();
		commands.add(getSwfmillPath());
		commands.add("simple");
		commands.add(info.getLibraryToCompile());
		commands.add(outputFile.getAbsolutePath());

		//
		// Create the process
		//
		Process process;
		BufferedInputStream errorStream;
		int returnVal;

		//
		// Run the process
		//
		try {
			process = new ProcessBuilder(commands).start();
			errorStream = new BufferedInputStream(process.getErrorStream());
			returnVal = process.waitFor();
		} catch (Exception e) {
			outputFile.delete(); // cleanup
			throw new CompileException("An error occurred during asset SWF "
					+ "compilation. See inner exception for details.", e);
		}

		//
		// Throw an exception if mtasc has issues.
		//
		if (returnVal != 0) {
			outputFile.delete(); // cleanup
			try {
				throw new CompileException(
						"swfmill encountered an error while "
								+ "compiling. The error was: "
								+ System.getProperty("line.separator")
								+ CompilerUtils
										.readStringFromStream(errorStream));
			} catch (IOException e) {
				throw new CompileException(
						"swfmill encountered an error while " + "compiling.");
			}
		}

		return outputFile;
	}

	/**
	 * Creates the output file for the compile operation. This file is actually
	 * created to prevent other threads from taking the name before the swf is
	 * built.
	 * 
	 * @return The temp file
	 * @throws IOException
	 *             If an error is encountered while creating the file
	 */
	private File createTempFile() throws IOException {
		//
		// Output path
		//
		File outputFile;
		synchronized (this) {
			do {
				outputFile = new File(tmpDir, CompilerUtils
						.generateRandomString(TMP_FILENAME_LENGTH));
			} while (outputFile.exists());

			//
			// Create a new file to prevent other threads from generating the
			// same name as we just did.
			//
			outputFile.createNewFile();
		}
		return outputFile;
	}

	/**
	 * Builds a command list from a build info object.
	 * 
	 * @param info
	 *            An object describing the build
	 * @param outputFile
	 *            The file object representing the swf that will be outputted by
	 *            the compile operation.
	 * @return A command list to be passed to the process builder
	 * @throws IOException
	 *             If an error creating files is encountered.
	 */
	private List<String> buildCommandList(BuildInfo info, File outputFile)
			throws CompileException {
		//
		// Create a commands list and fill it with arguments
		//
		List<String> commands = new ArrayList<String>();
		commands.add(getCompilerPath());

		//
		// Header
		//
		if (!info.buildUsesSwfmill()) {
			commands.add("-header");
			commands.add(info.getWidth() + ":" + info.getHeight() + ":"
					+ info.getFPS());
		}

		//
		// Version
		//
		commands.add("-version");
		commands.add(String.valueOf(info.getVersion()));

		//
		// Trace
		//
		commands.add("-trace");
		if (info.getTraceFunction() != null) {
			commands.add(info.getTraceFunction());
		} else {
			commands.add("no");
		}

		//
		// Optionals
		//
		if (info.hasMainMethod()) {
			commands.add("-main");
		}

		//
		// Class paths
		//
		if (info.getClassPaths() != null) {
			for (String cp : info.getClassPaths()) {
				commands.add("-cp");
				commands.add(cp);
			}
		}

		//
		// Packed classes
		//
		if (info.getPackedPackages() != null) {
			for (String pkg : info.getPackedPackages()) {
				commands.add("-pack");
				commands.add(pkg.replace('.', '/'));
			}
		}

		//
		// Excluded classes
		//
		if ((info.getPackedPackages() != null && info.excludeAllButPacked())
				|| info.getExclusions() != null) {
			//
			// Create the exclude file
			//
			File excludeFile;
			try {
				excludeFile = createTempFile();
			} catch (IOException e) {
				throw new CompileException(
						"An error was encountered when trying to"
								+ " to create the temporary exclude file. Please see the"
								+ " inner exception for details", e);
			}

			//
			// Build the exclude file
			//
			boolean flag = info.getExclusions() != null;
			try {
				new BuildExclusionGenerator(excludeFile.getAbsolutePath(), flag
						? info.getExclusions() : info.getPackedPackages(), info
						.getClassPaths(), flag);
			} catch (Exception e) {
				throw new CompileException(
						"An error was encountered when trying to"
								+ " to build the exclude file. See the"
								+ " inner exception for details", e);
			}
			
			//
			// Update the commands
			//
			commands.add("-exclude");
			commands.add(excludeFile.getAbsolutePath());
		}

		//
		// Output file
		//
		if (info.buildUsesSwfmill()) {
			commands.add("-out");
		} else {
			commands.add("-swf");
		}
		commands.add(outputFile.getAbsolutePath());

		//
		// File to compile
		//
		commands.add(info.getFileToCompile());

		return commands;
	}

	//
	// ============= CLEANUP
	//

	/**
	 * @param cleanOnFinalize
	 *            <code>true</code> if this object should remove all temporary
	 *            swfs that it created when it is finalized
	 */
	public void setCleanOnFinalize(boolean cleanOnFinalize) {
		this.cleanOnFinalize = cleanOnFinalize;
	}

	/**
	 * @return <code>true</code> if this object should remove all temporary
	 *         swfs that it created when it is finalized
	 */
	public boolean shouldCleanOnFinalize() {
		return cleanOnFinalize;
	}

	/**
	 * <p>
	 * Removes all temporary swfs created by this class. This method is called
	 * automatically when during finalization if
	 * <code>{@link #shouldCleanOnFinalize()}</code> returns <code>true</code>.
	 * </p>
	 */
	public void cleanup() {
		synchronized (temporaryFiles) {
			for (File f : temporaryFiles) {
				if (f.exists()) {
					f.delete();
				}
			}

			temporaryFiles.clear();
		}
	}
}
